Add TypeScript submodule support for SpacetimeDB modules - #5486
Open
aasoni wants to merge 14 commits into
Open
Conversation
aasoni
force-pushed
the
alessandro/ts-submodules
branch
5 times, most recently
from
July 10, 2026 11:40
80e8b25 to
5de4e3d
Compare
aasoni
force-pushed
the
alessandro/ts-submodules
branch
from
July 13, 2026 15:33
5de4e3d to
84c80c6
Compare
3 tasks
Contributor
There was a problem hiding this comment.
I think I'd like to have @joshua-spacetime look at the view changes.
cloutiertyler
requested changes
Jul 14, 2026
cloutiertyler
left a comment
Contributor
There was a problem hiding this comment.
This definitely needs some cleanup. The most notable things that need clean up are uses of RawIdentifier and NamespacedIdentifier::new_assume_valid after validating the module def.
Left comments for the other stuff.
- TypeScript server SDK: runtime API for module mounting (submodules), including namespaced dispatch, ctx.as helpers for cross-namespace calls, and HTTP handler delegation - TypeScript client codegen: namespace-aware table/reducer/procedure exports and query builder for submodule tables and views - Migrations: auto-migrate support for submodule tables, views, and indexes; AddConstraint step added alongside NamespacedIdentifier-based migration steps - CLI: namespace support for `spacetime call` and `spacetime sql` - C++/C# bindings: autogenerated RawSubmoduleV10 types - module-test-ts: submodule integration test (lib_submodule)
- constraint and sequence names didn't have appropriate namespaced prefix - root tables should match def name exactly - sql-parser didn't accept namespaced column in WHERE and JOIN clauses
cloutiertyler
force-pushed
the
alessandro/ts-submodules
branch
from
July 26, 2026 21:34
f13e73a to
9fc91ef
Compare
cloutiertyler
force-pushed
the
alessandro/ts-submodules
branch
2 times, most recently
from
July 27, 2026 01:16
607724d to
172c74d
Compare
Submodule tables were stored under the wrong name
-------------------------------------------------
`create_table_from_def_with_prefix` set a submodule table's canonical name from
its *accessor* name and then dropped the alias entirely. Accessor names exist
for client codegen; the host must never key on them. This made the host resolve
submodule tables by a codegen-only name and left `st_table_accessor` with no row
to recover the real accessor from.
The view path already did this correctly and said so in a comment, so the two
disagreed with each other; codegen had the same split, emitting
`prefix + accessor_name` as a table's wire name but `prefix + name` for views.
It was invisible only because accessor == canonical in the test fixture.
Submodule tables are now stored as `prefix + canonical name`, with the accessor
name kept as a namespaced alias, matching root tables. `check_compatible` goes
back to an exact name match rather than accepting either name.
`submodule_table_is_stored_under_canonical_name` covers this with a
case-converted name, where the two actually differ.
Local names were recovered by splitting on the last `.`
-------------------------------------------------------
`check_compatible` recovered an index, constraint, sequence or schedule function's
local name with `rsplit('.')`. A V9 sub-object name may itself contain dots -- the
`wacky_names` test builds `"wacky.index()"` -- so the last segment is not the local
name, and republishing such a module failed with "Index 0 not found in definition".
Master compared the stored name to `def.name` outright and was unaffected.
Now that every def records the namespace it is mounted under, the local name is
recovered by stripping exactly that prefix, via `NamespacePath::strip_from`, which
borrows rather than allocating. `wacky_names` covers the round-trip.
The table-name check was loose in the same place: it compared only the last segment,
so a table stored as `lib.my_table` validated against a root def named `my_table`.
It now checks the whole name, and `check_compatible_submodule_table_name` covers a
def actually mounted in `lib`.
The related *panic* is gone too: `auto_migrate_indexes` and friends built keys with
`Identifier::new(name).expect("names in a validated ModuleDef are valid identifiers")`,
which aborts outright on `"wacky.index()"`. Keying by `(namespace, name)` removes the
need to parse the name at all.
`ModuleDefLookup::key()` restored
---------------------------------
That naming split is why `key()` had to go: a table had two candidate keys.
With it fixed, each def records the `NamespacePath` its module is mounted under
(stamped by `ModuleDef::apply_namespace` once the module tree is assembled) and
its key is `(namespace, name)` -- a `Copy` pair, so `AutoMigrateStep` and
`AutoMigratePrecheck` go back to borrowing `Key<'def>` as they do on master.
The local name is stored once; the qualified form is built where it is actually
needed, which is the few places that hand a name to the database. That is 19
`format!`s at the same migration and table-creation sites this PR already had
them. They are per migration step and per table, never per row, and
`table_id_from_name` already allocates an owned `AlgebraicValue::String` to probe
the index, so the marginal cost is one short string on top of one that was
unavoidable.
Sub-objects (indexes, sequences, constraints) key on `(namespace, local name)`
rather than a dot-joined name, because a V9 sub-object name may itself contain
dots -- `wacky_names` covers exactly that -- and `stored_in_table_def` already
maps a sub-object to its table, so the key does not need to name the table.
Schedules are 1:1 with tables, so a table's key identifies its schedule.
`ModuleDef` now knows the path it is mounted under, so a namespaced key resolves
relative to whichever module it is looked up in -- from the root, or from the
submodule that owns the def.
This replaces the `find_*_by_full_name(&str)` helpers, which scanned every table
in the tree and `format!`ed each candidate, with `find_table`, `find_view` and
`find_storing_table`, which take keys and walk the submodule tree by segment.
`ensure_same_schema` filtered submodule steps with `name.contains('.')`; it now
asks whether the namespace is empty.
Type safety for namespaced names
--------------------------------
`RawIdentifier` was used for dot-delimited names, which is wrong: a name
containing `.` can never be validated into one `Identifier`. Add
`RawNamespacedIdentifier` and use it where a name may carry a namespace:
`TableName`, `SqlIdent`, index/constraint/sequence names and their `st_*` rows,
and query-planner relvar names. `ScheduleSchema::function_name` becomes a
`NamespacedIdentifier`.
`ReducerName` becomes fully qualified, so `local()` gives the name within its own
module and the `Deref<str>` gives the wire name. Previously it held a single
`Identifier`, so converting it to a `NamespacedIdentifier` yielded a one-segment
name -- `verify_token`, not `myauth.verify_token`.
The v1/v2 websocket message types are deliberately left as `RawIdentifier`.
They have always carried the table name as an opaque string and this feature
does not change that, so the narrowing happens at the boundary in `core` rather
than churning a published protocol crate.
This removes the `rsplit('.')` "bare name" hacks in `schema.rs` in favour of
`local_name()`, and namespace prefixing now goes through typed
`NamespacePath::{join, join_raw, join_namespaced}` instead of `format!` into a
`RawIdentifier`.
`Identifier::new_assume_valid` is renamed to `new_unsafe_assume_valid` so it
reads as the escape hatch it is; the schedule-name call site that used it to
smuggle a dotted name into an `Identifier` is gone.
Note this makes `TxDataTableEntry` and `MutTxId` grow, which the static size
assertions record. `TableName` and `ReducerName` wrap a `NamespacedIdentifier`,
which stores both the segments and their joined rendering. Those names genuinely
are qualified -- they are the database and wire identities -- so shrinking them
would mean changing how `NamespacedIdentifier` itself is represented.
Host boundary
-------------
`InstanceOp::name()` returned an owned `NamespacedIdentifier`, so every call
cloned; it returns a reference again. `ProcedureOp`/`HttpHandlerOp` build theirs
once at construction and `ReducerOp` borrows its `ReducerName`. Only
`start_funcall`, which takes the name by value, still clones. `start_funcall`
itself took `&str`/`RawIdentifier` and now takes a `NamespacedIdentifier` in v8,
wasmtime and `wasm_instance_env`.
A submodule reducer is now named by its qualified name in logs and metrics
rather than its bare local name.
Also fixes stale-view backing-table recreation, which iterated only root views
and looked them up in `st_view` by local name, so submodule views were never
repaired.
TypeScript
----------
- camelCase throughout `lib_submodule.ts`, `index.ts` and the submodules doc
- drop the `Anonymous extends true ? ... : ...` generic on `registerView` and
split it into `registerView` / `registerAnonymousView`, removing the
`as unknown as ViewFn<any, any, any>` cast; what remains is a single
documented schema-erasure assertion per flavour
- generated internals use `__`-prefixed names (`__qb`, `__reducerAccessors`,
`__procedureAccessors`), matching the rest of the emitted code
- the docs' subscription example used `addQuery`, which this branch removed
The camelCase rename is to the TypeScript *exports*; the wire name is the
canonical snake_case form and does not change. The docs now spell that out,
since the HTTP and CLI paths take the canonical name while the generated
bindings expose the camelCase accessor.
`submodule_reducer_wire_name_is_qualified_once` pins the codegen side, as no
snapshot fixture mounts a submodule.
Other
-----
- restore the step-ordering note inside `AutoMigrateStep` where it was
- restore the named bindings in `successful_auto_migration` to shrink the diff
- drop an unrelated `sender` -> `s` rename in the view-call path
- `RawNamespacedIdentifier::segments` collected a `Vec` per call to be an
`ExactSizeIterator`; no caller reads the size, so it just yields the `split`
- the docs said a submodule view is reachable as `<namespace>.<viewName>` in SQL.
SQL takes the canonical snake_case name; only the generated bindings expose the
camelCase accessor
cloutiertyler
force-pushed
the
alessandro/ts-submodules
branch
from
July 27, 2026 03:33
4cf6c54 to
7cd7dbb
Compare
cloutiertyler
approved these changes
Jul 27, 2026
cloutiertyler
left a comment
Contributor
There was a problem hiding this comment.
These are my fixes to this PR. It's not perfect, but it's better IMO. I do think we need to do some additional testing with these changes, but once that is done, I'm good to approve.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Add TypeScript submodule support for SpacetimeDB modules
Description of Changes
spacetime callandspacetime sqlAPI and ABI breaking changes
Adds a submodule field to ModuleDef. Older modules will be publishable to new servers, but new modules that have submodules will not be publishable to old servers.
Expected complexity level and risk
5
Testing
Beyond the rust tests defined in this PR, the following tests were done on the full PR sequence once the entire namespace feature was implemented for typescript:
Feature Test Checklist
Module:
Client
CLI
Migration
Commit Log